10.2.1 概述
1. Bitmap 在绘图中的使用
Bitmap 在绘图中相关的使用主要有两种:转换为 BitmapDrawable 对象使用;当做画布使用。
1)转换为 BitmapDrawable 对象使用
就是直接将 Bitmap 转换为 BitmapDrawable 对象,然后转换为 Drawable 使用。如下:
2)当做画布使用
在前面的章节中,已经不止一次地将 Bitmap 转换为画布。这里有两种使用方式。
方式一:使用默认画布
此处的 Canvas 里保存的就是一个 Bitmap,我们调用 Canvas 的各种绘图函数,最终都是画在这个 Bitmap 上的,而这个 Bitmap 就是默认画布。
方式二:自建画布
有时候我们需要在特定的 Bitmap 上作画,比如给照片加水印;或者我们只需要一块空白画布。在这些情况下,我们就需要自己来创建 Canvas 对象。
在上面的代码中,我们先创建一个空白的 Bitmap,然后再利用这个 Bitmap 创建一个 Canvas 对象,那么,调用 Canvas 的任何绘图函数最终都将画在这个 Bitmap 上。最后,我们可以将这个 Bitmap 保存到本地,也可以画到 View 上。
2. Bitmap 格式
我们都知道 Bitmap 是位图,也就是由一个个像素点组成的。所以,它肯定涉及两个问题:第一,如何存储每个像素点;第二,相关的像素点之间是否能够压缩,这也就涉及压缩算法的问题。
1)如何存储每个像素点
一张位图所占用的内存 = 图片长度(px) x 图片宽度(px) x 一个像素点占用的字节数。在 Android 中,存储一个像素点所使用的字节数是用枚举类型 Bitmap.Config 中的各个参数来表示的,如下图所示。
其中,A 代表透明度;R 代表红色;G 代表绿色;B 代表蓝色。
- ALPHA_8:表示 8 位 Alpha 位图,即 A = 8,表示只存储 Alpha 位,不存储颜色值。一个像素点占用 1 字节。它没有颜色,只有透明度。
- ARGB_4444:表示 16 位 ARGB 位图,即 A、R、G、B 各占 4 位,一个像素点占 4 + 4 + 4 + 4 = 16 位,2 字节。
- ARGB_8888:表示 32 位 ARGB 位图,即 A、R、G、B 各占 8 位,一个像素点占 8 + 8 + 8 + 8 = 32 位,4 字节。
- RGBA_F16:表示 64 位 RGBA 位图,8 字节,Android 8.0 新增。
- RGB_565:表示 16 位 RGB 位图,即 R 占 5 位,G 占 6 位,B 占 5 位,它没有透明度,一个像素点占 5 + 6 + 5 = 16 位,2 字节。
大家应该都知道,每个色值所占得位数越大,颜色越艳丽。为什么呢?
假设表示透明度的 A 占 4 位,我们来算一下,4 位的透明度有多少种取值?很明显,每位要么是 0,要么是 1,所以共有 $2^4$,也就是 16 种取值。假设透明度占 8 位呢?那么这个透明度就有 $2^8$,也就是 256 种取值。表示颜色值的 R、G、B 所占位数与颜色取值数的计算方式是一样的。很明显,取值数越多,所能表示的颜色就越多,颜色就越艳丽。
以上 5 种格式各自表示了以何种状态存储 Bitmap。ALPHA_8 格式只存储透明度,而不存储颜色值,由于所表示的内容太过简单,所以我们一般不用;RGB_565 格式只存储颜色值,而不存储透明度,透明度全部是 FF,假如对图片没有透明度要求,相比 ARGB_8888 格式将节省一半的内存开销;其他三种格式都是既存储透明度又存储颜色值,但 ARGB_4444 格式的画质惨不忍睹,在 API 13 中已经被弃用了。RGBA_F16 格式是最占内存的,同时也是画质最高的。如果对画质没那么高的要求,一般用 ARGB_8888 格式。
下面我们来看一下如何计算 Bitmap 所占的内存大小。
在讲解 Bitmap 所占内存大小之前,我们先明确一个概念:内存中存储的 Bitmap 对象与文件中存储的 Bitmap 图片不是一个概念。文件中存储的 Bitmap 图片是经过我们在后面降到的压缩算法压缩过得;而内存中存储的 Bitmap 对象是通过 BitmapFactory 或者 Bitmap 的 Create 方法创建的,它保存在内存中,而且具有明确的宽和高。所以,很明显,内存中存储的一个 Bitmap 对象,它所占的内存大小 = Bitmap 的宽 x Bitmap 的高 x 每个像素所占内存大小。
很多读者一旦需要画布,就会创建一个全屏幕大小的 Bitmap 作为画布。我们现在就来算一下在一个分辨率是 1024 像素 x 768 像素的屏幕上,创建一个与屏幕同样大小的 Bitmap,到底需要多少内存?也就是说,这个屏幕长度上有 1024 个像素,宽度上有 768 个像素。我们假设每个像素使用 ARGB_8888 格式来存储,也就是一个像素占 32 位,那么要全屏显示这张图片所占的内存大小 = 1024 x 768 x 32B = 25 165 824B = 24MB。全屏显示一张图片要用 24MB。而且更恐怖的是,有些人还会循环创建。这也是在有些人自定义的控件中经常出现 OOM 的原因。所以,我们在创建画布时,应尽量根据需要的大小来创建。
2)Bitmap 压缩格式
在 Android 中,压缩格式使用枚举类 Bitmap.CompressFormat 中的成员变量表示,如下图所示。
其实这个参数很简单,就是指定 Bitmap 是以 JPEG、PNG 还是 WEBP 格式来压缩的,每种格式对应一种压缩算法。有关各种压缩算法的具体效果,我们会在 10.2.5 节中具体讲解。
10.2.2 创建 Bitmap 方法之一:BitmapFactory
BitmapFactory 用于从各种资源、文件、数据流和字节数组中创建 Bitmap(位图)对象。BitmapFactory 类是一个工具类,提供了大量的函数,这些函数可用于从不同的数据源中解析、创建 Bitmap(位图)对象。
单从这些函数中就可以看出,BitmapFactory 的功能很强大,可以针对资源、文件、字节数组、FileDescriptor 和 InputStream 数据流解析出对应的 Bitmap 对象,如果解析不出来,则返回 null。而且每个函数都有两个实现,两个实现之间只差一个 Options opts 参数(详见 10.2.3 节中讲述)。
1. decodeResource(Resources res, int id)
这个函数表示从资源中解码一张位图,主要以 R.drawable.xxx 形式从本地资源中加载。
- Resources res:包含图像数据的资源对象,一般通过 Context.getResource() 函数获得。
- int id:包含图像数据的资源 id。
示例代码:
2. decodeFile(String pathName)
这个函数的主要作用是通过文件路径来加载图片。在实际中,一般在从相册中加载图片或者拍照使用,首先通过 intent 打开相册或摄像头,然后通过 onActivityResult() 函数获取图片 URI,再根据 URI 获取图片路径,最后根据路径解析出图片。其过程详见 拍照、相册及裁剪的终极实现系列。
- String pathName:解码文件的全路径名。必须是全路径名。
使用示例:
3. decodeByteArray(byte[] data, int offset, int length)
根据 Byte 数组来解析出 Bitmap。
- byte[] data:压缩图像数据的字节数组。
- int offset:图像数据偏移量,用于解码器定位从哪里开始解析。
- int length:字节数,从偏移量开始,指定取多少字节进行解析。
伪代码:
因为 BitmapFactory.decodeByteArray() 函数所需的 data 字节数组并不是想象中的数组,而是把输入流转换成字节内存输出流的字节数组格式。如果不经过 OutputStream 转换,直接返回从 InputStream 中读取到的 byte 数组,那么 decodeByteArray() 函数将一直返回 null。
4. decodeFileDescriptor
有两个构造函数,其参数:
- FileDescriptor fd:包含解码位图数据的文件路径。
- Rect outPadding:用于返回矩形的内边距。如果 Bitmap 没有被解析成功,则返回 (-1, -1, -1, -1);如果不需要,则可以传入 null。这个参数一般不使用。
示例代码:
在 Android 老版本中,BitmapFactory.decodeFileDescriptor() 解析方法比使用 BitmapFactory.decodeFile(path) 更节省内存。对比源码发现,前者是直接调用 nativeDecodeFileDescriptor() 函数,它是 Android Native 里的函数,被封装在 SO 里;而追踪 decodeFile() 函数发现,在最终调用 nativeDecodeStream() 函数之前,最多可能会申请两次空间。在 API 28 中源码没有发现多处申请内存空间的问题。
5. decodeStream
|
|
- InputStream is:用于解码位图的原始输入流。
- Rect outPadding:用于返回矩形的内边距。如果 Bitmap 没有被解析成功,则返回 (-1, -1, -1, -1);如果不需要,则可以传入 null。这个参数一般不使用。
对前面 decodeByteArray 示例代码进行改造:
10.2.3 BitmapFactory.Options
这个参数的作用非常大,它可以设置 Bitmap 的采样率,通过改变图片的宽度、高度、缩放比例等,以减少图片的像素的目的。总的来说,通过设置这个值,可以更好地控制、显示、使用 Bitmap。在实际开发中可以灵活使用该值,以降低 OOM 的发生概率。
下面列出常用的部分成员变量。
以 in 开头的代表的就是设置某某参数;以 out 开头的代表的就是获取某某参数。比如,inSampleSize 就是设置 Bitmap 的缩放比例,outWidth 就是获取 Bitmap 的高度。
1. inJustDecodeBounds 获取图片信息
将这个字段设置为 true,则表示只解析图片信息,不获取图片,不分配内存。能获取的信息有图片的宽度、高度和图片的 MIME 类型。图片的宽度、高度通过 options.outWidth (图片的原始宽度) 和 options.outHeight (图片的原始高度) 返回;图片的 MIME 类型通过 options.outMimeType 返回。
从结果中看可以看出,返回的 Bitmap 是 null,而获取到的 width 和 height 都是有值的。这就证明了我们的结论:inJustDecodeBounds 只会解析 Bitmap 的宽/高参数,而不会解析 Bitmap,整个过程是不占内存的。
2. inSampleSize 压缩图片
这个字段表示采样频率,简称采样率,是指每隔多少个样本采样一次作为结果。比如,将这个字段设置为 4,意思就是从原本图片的 4 个像素中取一个像素作为结果返回,其余的都丢弃,这样,结果图片的宽和高都为原来的 1/4。同样,如果将这个字段设置为 16,意思就是从每 16 个像素中取一个像素返回,同样,宽和高都为原来的 1/16。很明显,采样率越大,图片越小,同时图片越失真。
针对 inSampleSize 的值,官方建议取 2 的冥数,比如 1、2、4、8、16 等,否则会被系统向下取整并找到一个最接近的值。不能去小于 1 的值,否则系统将一直使用 1 来作为采样率。
所以,这个参数主要用来对图像进行压缩。那如何确定一张图片的采样率呢?那就是使得缩放后的图片尺寸尽量大于等于相应的 ImageView 大小。一般计算 inSampleSize 的步骤如下。
第一步,获取图片的原始宽高。通过将 Options 对象的 inJustDecodeBounds 属性设置为 true 后调用 decodeResource() 函数,可以实现不真正加载图片而只获取图片的尺寸信息。代码如下:
第二步,根据原始宽高和目标宽高计算出 inSampleSize。代码如下:
第三步,根据采样率解析出压缩后的 Bitmap。代码如下:
3. 加载一个 Bitmap 文件究竟要占多少空间
为了适配不同的屏幕,Android 系统预先准备了几个资源文件夹。
文件夹 | drawable-ldpi | drawable-mdpi | drawable-hdpi | drawable-xhdpi | drawable-xxhdpi | drawable-xxxhdpi |
---|---|---|---|---|---|---|
density | 1 | 1.5 | 2 | 3 | 3.5 | 4 |
densityDpi | 160 | 240 | 320 | 480 | 560 | 640 |
- density:表示 dpi 与 px 的换算比例。
- densityDpi:表示在对应的分辨率下每英寸有多少个 dpi。
即:屏幕上 1 英寸长所对应的 px 数 = density × densityDpi。Android 系统在加载图片时会根据需要动态缩放图片所占的像素数,也就是会动态缩放图片的尺寸。
比如,有一张 640px × 800px 的图片存放在 xhdpi 文件夹下,这个文件夹所对应的屏幕分辨率是 480dpi,而当真实的屏幕分辨率是 720dpi 的时候,就需要放大此图,以适配这个屏幕,放大倍数就是 720 / 480 = 1.5。加载到内存时,Bitmap 对象的尺寸是 960px × 1200px。因为 Bitmap 默认使用 ARGB_8888格式来存储,也就是每个像素占 4 个字节,所以实际所占得内存字节数为 640px × 1.5 × 800px × 1.5 × 4 = 4608000。但是从 SD 卡加载同样的图片,就不会进行缩放,所占的内存为 640px × 800px × 4 = 2048000。
- 不同名称的资源文件夹是为了适配不同的屏幕分辨率的,当屏幕分辨率与文件所在资源文件夹对应的分辨率相同时,直接使用图片,不会对图片进行缩放。
- 当屏幕分辨率与图片所在文件夹所对应的分辨率不同时,会进行缩放,缩放比例是:屏幕分辨率 / 文件夹所对应的分辨率。
- 当从本地文件夹中加载图片时,不会对图片进行缩放。
4. inScaled、inDensity、inTargetDensity、inScreenDensity
- inScaled:在需要缩放时,是否对当前文件进行缩放。值为 false 表示不缩放;值为 true 或者不设置,则会根据文件夹分辨率和屏幕分辨率动态缩放。默认为 true。
- inDensity:用于设置文件所在资源文件夹的屏幕分辨率。
- inTargetDensity:表示真实显示的屏幕分辨率。
- inScreenDensity:在源码中没有用到此参数,不表。
一张图片的缩放比例是通过屏幕真实的分辨率 / 所在资源文件夹所对应的分辨率得出来的,在这里,也就是缩放比例 scale = inTargetDensity / inDensity。这俩个参数的作用就是:可以通过手动设置文件所在资源文件夹的分辨率和真实显示的屏幕分辨率来指定图片的缩放比例。
5. inPreferredConfig
这个参数用来设置像素的存储格式的。
10.2.4 创建 Bitmap 方法之二:Bitmap 静态方法
|
|
1. static Bitmap createBitmap(int width, int height, Bitmap.Config config)
这个函数可以创建一幅指定大小的空白图像。
|
|
2. createBitmap(Bitmap source, int x, int y, int width, int height)
这个函数主要用于裁剪图像,各参数的含义如下。
- Bitmap source:用于裁剪的源图像。
- int x, y:开始裁剪的位置点坐标。
- int width, height:裁剪的宽度和高度。
|
|
这里只是将图像裁剪成矩形。若想把图像裁剪成圆形或者椭圆形,不是使用 Bitmap 的自带方法,而需要用到 Xfermode 图像混合的知识,详见 8.3.2 节。
3. createBitmap(Bitmap source, int x, int y, int width, int height, Matrix m, boolean filter)
这个函数相比上面的裁剪函数多了两个参数:Matrix m 和 boolean filter。它的作用也很明显,就是不仅能实现裁剪,还能给裁剪后的图像添加矩阵。
- Matrix m:给裁剪后的图像添加矩阵。
- boolean filter:对应 paint.setFilterBitmap(filter),即是否给图像添加滤波效果。如果设置为 true,则能够减少图像中由于噪声引起的突兀的孤立像素点或像素块。
|
|
将裁剪后的小狗宽度方向放大两倍。
4. createBitmap(Bitmap src, int dstWidth, int dstHeight, boolean filter)
该函数用于缩放 Bitmap。各参数的含义如下。
- Bitmap src:需要缩放的源图像。
- int dstWidth, dstHeight:缩放后的目标宽高。
- boolean filter:是否给图像添加滤波效果,对应 paint.setFilterBitmap(filter)
5. 建议
在加载或创建 Bitmap 时,必须如下面代码所示,通过 try…catch 语句捕捉 OutOfMemoryError,以防出现 OOM 问题。
10.2.5 常用函数
1. copy(Config config, boolean isMutable)
根据源图像创建一个副本,可以指定副本的像素存储格式。
- Config config:像素在内存中的存储格式。取值为 ARGB_8888等。
- boolean isMutable:新创建的 Bitmap 是否可以更改其中的像素。
我们可以使用下面的方法来判断当前的 Bitmap 是不是像素可更改的。
返回 true 表示像素可以更改的。如果像素是不可更改的,但仍要使用 setPixel() 等函数设置 Bitmap 中的像素值时,就会报错。通过 BitmapFactory 加载的 Bitmap 都是像素不可更改的,只有通过 Bitmap 中的几个函数创建的 Bitmap 才是像素可更改的。这些函数如下:
对于像素不可更改的图像,是不能作为画布的,比如下面的代码就会报错:
显然,srcBmp 是像素不可更改的,然而,当其作为 Canvas 以后,如果要向其中填充颜色,则必然会改变它的像素值,肯定为报错。
2. extractAlpha()
这个函数的主要作用是从 Bitmap 中抽取出 Alpha 值,生成一幅只含有 Alpha 值的图像,像素存储格式是 ALPHA_8。它有两个构造函数。
1)Bitmap extractAlpha()
示例:将图像的透明通道抽取出来,并染成天蓝色。
|
|
2)Bitmap extractAlpha(Paint paint, int[] offsetXY)
- Paint paint:具有 MaskFilter 效果的 Paint 对象,一般使用 BlurMaskFilter 模糊效果。
- int[] offsetXY:返回在添加 BlurMaskFilter 效果以后原点的偏移量。比如,我们使用一个半径为 6 的 BlurMaskFilter 效果,那么在源图像被模糊以后,图像的上下左右 4 条边都会多出 6px 的模糊效果。所以,要想完全显示这幅图像,就不应该从源图像左上角 (0, 0) 点开始绘制,而应从 (-6, -6) 点开始绘制,而 offsetXY 就是相对源图像的建议绘制起始位置,所以此时 offsetXY 的值就是 [-6, -6]。注意,offsetXY 只是建议的绘制起始位置,其取值并不一定与 BlurMaskFilter 的模糊半径一致。
利用这个模糊效果,可以实现发光效果,如下图所示。
|
|
3)示例:单击描边效果
|
|
3. 分配空间获取
|
|
所以,一般计算 Bitmap 内存占用的函数会写成如下这样:
4. recycle()、isRecycled()
这是两个与图片回收有关的函数,其声明如下:
所以,如果要回收内存,则代码一般这样写:
注意:使用内存已经被回收的 Bitmap 引起 Crash;在 API 10 及以前的版本中,必须强制调用 recycle() 函数来释放内存;从 API 11 开始,不再强制调用 recycle() 函数来释放内存。
5. setDensity()、getDensity()
在 BitmapFactory 中,我们讲过几个 Density 值,如 inDensity、inTargetDensity,而这里 Bitmap 的 setDensity()、getDensity() 函数所对应的就是 inDensity。inDensity 用于表示该 Bitmap 适合的屏幕 dpi,当目标屏幕的 dpi (inTargetDensity) 不等于它时,将会缩放图像以适应目标机器。
6. setPixel()、getPixel()
这两个函数用于针对 Bitmap 中某个位置的像素进行设置和获取。举个例子:将图片中的绿色通道增大 30,如下图所示。
完整代码如下:
7. compress()
1)概述
用于压缩图像,它会将压缩过得 Bitmap 写入指定的输出流中。函数声明如下:
- CompressFormat format:压缩格式,取值有:CompressFormat.JPEG、CompressFormat.PNG、CompressFormat.WEBP (API 14)。
- int quality:表示压缩后图像的画质,取值是 0~100。0 表示 以最低画质压缩,100 表示以最高画质压缩。对于 PNG 等无损格式的图片,会忽略此项设置。
- OutputStream stream:这是输出值,Bitmap 在被压缩后,会以 OutputStream 的形式在这里输出。
- 返回值 boolean:当压缩成功后,返回 true;失败则返回 false。
2)压缩格式
- CompressFormat.JPEG: 采用 JPEG 压缩算法,是一种有损压缩方式,即在压缩过程中会改变图像的原本质量。compress() 函数中的 quality 参数值越小,画质越差,对图片的原有质量损伤越大,但是得到的图片文件比较小。而且,JPEG 不支持 Alpha 透明度,当遇到透明度像素时,会以黑色背景填充。
- CompressFormat.PNG:采用 PNG 压缩算法,是一种支持透明度的无损压缩格式。
- CompressFormat.WEBP:WEBP 是一种同时提供了有损压缩与无损压缩的图片文件格式,派生自视频编码格式 VP8;从 Android 4.0(API 14)开始支持 WEBP,从 Android 4.2.1+(API 18)开始支持无损 WEBP 和带 Alpha 通道的 WEBP。从整体来讲,WEBP 格式是通过牺牲压缩时间来减小产出文件大小的。
3)压缩图像
|
|
4)示例:保存压缩后的图像
|
|
10.2.6 常见问题
1. 对 Bitmap 的画笔设置 ANTI_ALIAS_FLAG 属性,为什么无效
简单来说,ANTI_ALIAS_FLAG 属性通过混合前景色与背景色来产生平滑的边缘。比如背景色是透明的,而前景色是红色的,ANTI_ALIAS_FLAG 属性通过将边缘处的像素由纯色逐步转换为透明来让边缘看起来是平滑的。
而当我们在 Bitmap 上重绘时,像素的颜色会越来越纯粹,从而导致边缘越来越粗糙。所以,可以有两种选择即可避免设置 ANTI_ALIAS_FLAG 属性无效的问题:
- 避免重绘。
- 在重绘前清空 Bitmap。
避免重绘的方法很简单,只需要保证让 Bitmap 只被绘制一次即可,比如将 Bitmap 绘制操作放在初始化的时候,而不要放在可能被多次调用的 onDraw()、onMeasure()、onLayout() 等函数中。
清空 Bitmap 可以参考如下:
2. 如何生成水印
其实原理很简单,新生成一个 Bitmap,先后将源 Bitmap 和水印 Bitmap 画上去即可。
|
|